page.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. 'use client';
  2. import { use, useEffect, useMemo, useState, useRef, useCallback } from 'react';
  3. import { createPortal } from 'react-dom';
  4. import * as signalR from '@microsoft/signalr';
  5. import { Pause, Play, SkipForward, Bell, BellOff, Volume2, Image as ImageIcon, MoreVertical, X, AlertTriangle, Wifi, WifiOff, ChevronUp, ChevronDown } from 'lucide-react';
  6. import { DonationAlertData, DonationRemoteState, AlertQueueItem, AlertStatusKey, RemoteCrewItem, RemoteCrewMembersResponse, RemoteCrewMemberItem } from '@/types/donation';
  7. import { fetchApi } from '@/lib/utils/client';
  8. import './style.scss';
  9. type Props = {
  10. params: Promise<{ widgetToken: string }>;
  11. };
  12. const STATUS_LABEL: Record<AlertStatusKey, string> = {
  13. queued: '대기',
  14. playing: '재생 중',
  15. delivered: '완료',
  16. skipped: '건너뜀',
  17. ignored: '무시',
  18. failed: '실패'
  19. };
  20. type PlayingMeta = { startedAt: number; durationSec: number };
  21. /**
  22. * 둥근 시계형 카운트다운 — 보라색 호가 시계방향으로 차오르며 남은 초 표시.
  23. * 매 250ms 다시 렌더링 (성능 negligible).
  24. */
  25. function TimerRing({ meta }: { meta: PlayingMeta }) {
  26. const [now, setNow] = useState<number>(() => Date.now());
  27. useEffect(() => {
  28. const id = setInterval(() => setNow(Date.now()), 250);
  29. return () => clearInterval(id);
  30. }, []);
  31. const elapsedSec = (now - meta.startedAt) / 1000;
  32. const remainingSec = Math.max(0, meta.durationSec - elapsedSec);
  33. const progress = Math.min(1, elapsedSec / meta.durationSec);
  34. const r = 12;
  35. const c = 2 * Math.PI * r;
  36. const offset = c * (1 - progress);
  37. return (
  38. <span className="dpot-remote__timer-ring" aria-label={`남은 시간 ${Math.ceil(remainingSec)}초`}>
  39. <svg width="30" height="30" viewBox="0 0 30 30">
  40. <circle cx="15" cy="15" r={r} fill="none" stroke="rgba(255,255,255,0.15)" strokeWidth="2.5" />
  41. <circle
  42. cx="15" cy="15" r={r}
  43. fill="none"
  44. stroke="#7c3aed"
  45. strokeWidth="2.5"
  46. strokeLinecap="round"
  47. strokeDasharray={c}
  48. strokeDashoffset={offset}
  49. transform="rotate(-90 15 15)"
  50. style={{ transition: 'stroke-dashoffset 0.25s linear' }}
  51. />
  52. </svg>
  53. <span className="dpot-remote__timer-num">{Math.ceil(remainingSec)}</span>
  54. </span>
  55. );
  56. }
  57. type TabKey = 'all'|'queued'|'ignored'|'failed'|'skipped'|'delivered';
  58. const TAB_DEFS: { key: TabKey; label: string }[] = [
  59. { key: 'all', label: '전체' },
  60. { key: 'queued', label: '대기' },
  61. { key: 'ignored', label: '무시' },
  62. { key: 'failed', label: '실패' },
  63. { key: 'skipped', label: '건너뜀' },
  64. { key: 'delivered', label: '완료' }
  65. ];
  66. export default function RemotePage({ params }: Props)
  67. {
  68. const { widgetToken } = use(params);
  69. const apiBase = '/api/donation/remote';
  70. const hubUrl = process.env.NEXT_PUBLIC_API_URL + '/hubs/donation';
  71. const [connected, setConnected] = useState(false);
  72. const [state, setState] = useState<DonationRemoteState>({ isPaused: false, isAccepting: true, isAudioOnly: false, isVideoOnly: false });
  73. const [queue, setQueue] = useState<AlertQueueItem[]>([]);
  74. const [openMenuID, setOpenMenuID] = useState<number|null>(null);
  75. const [menuPos, setMenuPos] = useState<{ top: number; right: number }|null>(null);
  76. const [activeTab, setActiveTab] = useState<TabKey>('all');
  77. const [isPopup, setIsPopup] = useState(false);
  78. const [playingMeta, setPlayingMeta] = useState<Record<number, PlayingMeta>>({});
  79. // 통합 테스트 후원 form (selectedCrewID null 이면 일반, 값 있으면 크루 후원)
  80. const [testName, setTestName] = useState('');
  81. const [testAmount, setTestAmount] = useState<number>(1000);
  82. const [testMessage, setTestMessage] = useState('');
  83. const [isTestMode, setIsTestMode] = useState(true);
  84. const [testSubmitting, setTestSubmitting] = useState(false);
  85. const [crews, setCrews] = useState<RemoteCrewItem[]>([]);
  86. const [crewMembers, setCrewMembers] = useState<RemoteCrewMemberItem[]>([]);
  87. const [selectedCrewID, setSelectedCrewID] = useState<number|null>(null);
  88. const [selectedMemberID, setSelectedMemberID] = useState<number|null>(null);
  89. const connectionRef = useRef<signalR.HubConnection|null>(null);
  90. useEffect(() => {
  91. setIsPopup(typeof window !== 'undefined' && !!window.opener);
  92. }, []);
  93. // 메뉴 외부 클릭 시 닫기
  94. useEffect(() => {
  95. if (openMenuID === null) {
  96. return;
  97. }
  98. const handler = (e: MouseEvent) => {
  99. const target = e.target as HTMLElement;
  100. if (!target.closest('.dpot-remote__menu-dropdown') && !target.closest('.dpot-remote__menu-btn')) {
  101. setOpenMenuID(null);
  102. setMenuPos(null);
  103. }
  104. };
  105. document.addEventListener('click', handler);
  106. return () => document.removeEventListener('click', handler);
  107. }, [openMenuID]);
  108. const toggleMenu = (alertID: number, e: React.MouseEvent<HTMLButtonElement>) => {
  109. if (openMenuID === alertID) {
  110. setOpenMenuID(null);
  111. setMenuPos(null);
  112. return;
  113. }
  114. const rect = e.currentTarget.getBoundingClientRect();
  115. setMenuPos({
  116. top: rect.bottom + 4,
  117. right: window.innerWidth - rect.right
  118. });
  119. setOpenMenuID(alertID);
  120. };
  121. const closeMenu = () => {
  122. setOpenMenuID(null);
  123. setMenuPos(null);
  124. };
  125. const loadState = useCallback(async () => {
  126. try {
  127. const res = await fetchApi<DonationRemoteState & { queue: AlertQueueItem[] }>(`${apiBase}/state/${widgetToken}`, { silent: true });
  128. if (res.data) {
  129. setState({ isPaused: res.data.isPaused, isAccepting: res.data.isAccepting, isAudioOnly: res.data.isAudioOnly, isVideoOnly: res.data.isVideoOnly });
  130. setQueue(res.data.queue || []);
  131. }
  132. } catch {}
  133. }, [apiBase, widgetToken]);
  134. const loadCrews = useCallback(async () => {
  135. try {
  136. const res = await fetchApi<{ crews: RemoteCrewItem[] }>(`${apiBase}/crews/${widgetToken}`, { silent: true });
  137. if (res.data) {
  138. setCrews(res.data.crews);
  139. }
  140. } catch {}
  141. }, [apiBase, widgetToken]);
  142. const loadCrewMembers = useCallback(async (crewID: number) => {
  143. try {
  144. const res = await fetchApi<RemoteCrewMembersResponse>(`${apiBase}/crews/${widgetToken}/${crewID}/members`, { silent: true });
  145. if (res.data) {
  146. setCrewMembers(res.data.members);
  147. setSelectedMemberID(null);
  148. }
  149. } catch {}
  150. }, [apiBase, widgetToken]);
  151. const connectHub = useCallback(() => {
  152. const conn = new signalR.HubConnectionBuilder().withUrl(hubUrl).withAutomaticReconnect().build();
  153. conn.on('ReceiveAlert', (data: DonationAlertData) => {
  154. setQueue(prev => {
  155. const existing = prev.find(q => q.alertID === data.alertID);
  156. if (existing) {
  157. return prev.map(q => q.alertID === data.alertID ? { ...q, status: 'queued' as AlertStatusKey } : q);
  158. }
  159. const item: AlertQueueItem = {
  160. alertID: data.alertID,
  161. donationID: data.donationID,
  162. status: 'queued',
  163. sponsorMemberID: data.sponsorMemberID,
  164. sendName: data.sendName,
  165. amount: data.amount,
  166. message: data.message,
  167. isTest: data.isTest,
  168. createdAt: data.createdAt
  169. };
  170. return [item, ...prev];
  171. });
  172. });
  173. conn.on('ReceiveState', (s: DonationRemoteState) => setState(s));
  174. conn.on('ReceiveSkip', () => {
  175. setQueue(prev => prev.map(q => q.status === 'playing' ? { ...q, status: 'skipped' } : q));
  176. });
  177. conn.on('ReceiveQueueReorder', (data: { swap?: number[] }) => {
  178. if (!data?.swap || data.swap.length !== 2) {
  179. return;
  180. }
  181. const [a, b] = data.swap;
  182. setQueue(prev => {
  183. const idxA = prev.findIndex(q => q.alertID === a);
  184. const idxB = prev.findIndex(q => q.alertID === b);
  185. if (idxA < 0 || idxB < 0) {
  186. return prev;
  187. }
  188. const next = [...prev];
  189. [next[idxA], next[idxB]] = [next[idxB], next[idxA]];
  190. return next;
  191. });
  192. });
  193. conn.on('ReceiveAlertStatusUpdate', (update: { alertID: number; status: AlertStatusKey; displayDurationSec?: number|null; startedAt?: string|null }) => {
  194. setQueue(prev => prev.map(q => q.alertID === update.alertID ? { ...q, status: update.status } : q));
  195. // playing 진입 시 타이머 ring 시작용 메타 저장
  196. if (update.status === 'playing' && update.displayDurationSec && update.displayDurationSec > 0) {
  197. const started = update.startedAt ? new Date(update.startedAt).getTime() : Date.now();
  198. setPlayingMeta(prev => ({ ...prev, [update.alertID]: { startedAt: started, durationSec: update.displayDurationSec! } }));
  199. } else if (update.status !== 'playing') {
  200. // playing 종료 → 메타 제거 (memory leak 방지)
  201. setPlayingMeta(prev => {
  202. if (!(update.alertID in prev)) {
  203. return prev;
  204. }
  205. const next = { ...prev };
  206. delete next[update.alertID];
  207. return next;
  208. });
  209. }
  210. });
  211. conn.start().then(() => {
  212. conn.invoke('JoinChannel', widgetToken);
  213. setConnected(true);
  214. }).catch(() => {});
  215. conn.onclose(() => setConnected(false));
  216. conn.onreconnected(() => { conn.invoke('JoinChannel', widgetToken); setConnected(true); });
  217. connectionRef.current = conn;
  218. }, [hubUrl, widgetToken]);
  219. useEffect(() => {
  220. loadState();
  221. loadCrews();
  222. connectHub();
  223. return () => { connectionRef.current?.stop(); };
  224. }, [loadState, loadCrews, connectHub]);
  225. useEffect(() => {
  226. if (selectedCrewID) {
  227. loadCrewMembers(selectedCrewID);
  228. } else {
  229. setCrewMembers([]);
  230. setSelectedMemberID(null);
  231. }
  232. }, [selectedCrewID, loadCrewMembers]);
  233. const togglePause = async () => {
  234. const next = { ...state, isPaused: !state.isPaused };
  235. setState(next);
  236. await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
  237. };
  238. const toggleAccepting = async () => {
  239. const next = { ...state, isAccepting: !state.isAccepting };
  240. setState(next);
  241. await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
  242. };
  243. const toggleAudioOnly = async () => {
  244. const next = { ...state, isAudioOnly: !state.isAudioOnly, isVideoOnly: false };
  245. setState(next);
  246. await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
  247. };
  248. const toggleVideoOnly = async () => {
  249. const next = { ...state, isVideoOnly: !state.isVideoOnly, isAudioOnly: false };
  250. setState(next);
  251. await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
  252. };
  253. const skipCurrent = async () => {
  254. await fetchApi(`${apiBase}/skip/${widgetToken}`, { method: 'POST', silent: true });
  255. };
  256. const ignoreAlert = async (alertID: number) => {
  257. await fetchApi(`${apiBase}/ignore/${alertID}`, { method: 'POST', silent: true });
  258. setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'ignored' } : q));
  259. closeMenu();
  260. };
  261. // 일시정지 중일 때만, queued 상태 인접 알림과 swap
  262. const swapAlert = async (alertID: number, direction: 'up'|'down') => {
  263. const queuedItems = filtered.filter(q => q.status === 'queued');
  264. const idx = queuedItems.findIndex(q => q.alertID === alertID);
  265. if (idx < 0) {
  266. return;
  267. }
  268. const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
  269. if (targetIdx < 0 || targetIdx >= queuedItems.length) {
  270. return;
  271. }
  272. const partner = queuedItems[targetIdx];
  273. await fetchApi(`${apiBase}/reorder`, {
  274. method: 'POST',
  275. body: { widgetToken, alertIDA: alertID, alertIDB: partner.alertID },
  276. silent: true
  277. });
  278. // 백엔드가 broadcast 한 ReceiveQueueReorder 가 도착하면 화면 갱신됨
  279. };
  280. const resendAlert = async (alertID: number) => {
  281. await fetchApi(`${apiBase}/resend/${alertID}`, { method: 'POST', silent: true });
  282. setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'queued' } : q));
  283. closeMenu();
  284. };
  285. const playNow = async (alertID: number) => {
  286. closeMenu();
  287. // status 변경은 SignalR ReceiveAlertStatusUpdate에 맡김 (위젯이 재생 시작 → 'playing' broadcast)
  288. // 직접 setQueue 하면 race condition으로 'playing'을 덮어씀
  289. await fetchApi(`${apiBase}/play-now/${alertID}`, { method: 'POST', silent: true });
  290. };
  291. const markDelivered = async (alertID: number) => {
  292. closeMenu();
  293. // Playing 상태에서 stuck된 알림 강제 완료 처리 (위젯 종료/네트워크 단절 등)
  294. // status 변경은 SignalR broadcast로 동기화
  295. await fetchApi(`${apiBase}/mark-delivered/${alertID}`, { method: 'POST', silent: true });
  296. };
  297. const submitTestDonate = async () => {
  298. if (!testName.trim()) {
  299. alert('별명을 입력하세요.');
  300. return;
  301. }
  302. if (testAmount < 1000) {
  303. alert('최소 후원 금액은 1,000원입니다.');
  304. return;
  305. }
  306. if (selectedCrewID && (selectedMemberID === null || selectedMemberID <= 0)) {
  307. alert('크루원을 선택하세요.');
  308. return;
  309. }
  310. if (!isTestMode) {
  311. const isCrew = !!selectedCrewID;
  312. const msg = isCrew
  313. ? '영구 후원으로 기록됩니다. 활성 크루 세션이 있으면 리더보드에 합산됩니다. 계속하시겠습니까?'
  314. : '영구 후원으로 기록됩니다. 본인 채널 통계에 반영됩니다. 계속하시겠습니까?';
  315. const ok = window.confirm(msg);
  316. if (!ok) {
  317. return;
  318. }
  319. }
  320. setTestSubmitting(true);
  321. try {
  322. await fetchApi(`${apiBase}/test-donate`, {
  323. method: 'POST',
  324. body: {
  325. widgetToken,
  326. sendName: testName.trim(),
  327. amount: testAmount,
  328. message: testMessage.trim() || null,
  329. crewMemberID: selectedCrewID ? selectedMemberID : null,
  330. isTest: isTestMode
  331. },
  332. silent: false
  333. });
  334. setTestMessage('');
  335. } catch (e) {
  336. console.error('test-donate failed:', e);
  337. } finally {
  338. setTestSubmitting(false);
  339. }
  340. };
  341. const counts = useMemo(() => ({
  342. all: queue.length,
  343. queued: queue.filter(q => q.status === 'queued' || q.status === 'playing').length,
  344. ignored: queue.filter(q => q.status === 'ignored').length,
  345. failed: queue.filter(q => q.status === 'failed').length,
  346. skipped: queue.filter(q => q.status === 'skipped').length,
  347. delivered: queue.filter(q => q.status === 'delivered').length
  348. }), [queue]);
  349. const filtered = useMemo(() => {
  350. if (activeTab === 'all') {
  351. return queue;
  352. }
  353. if (activeTab === 'queued') {
  354. return queue.filter(q => q.status === 'queued' || q.status === 'playing');
  355. }
  356. return queue.filter(q => q.status === activeTab);
  357. }, [activeTab, queue]);
  358. const formatTime = (dateStr: string) => {
  359. const d = new Date(dateStr);
  360. return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
  361. };
  362. return (
  363. <div className="dpot-remote">
  364. {/* 상단 상태 배너 */}
  365. <div className={`dpot-remote__banner${state.isPaused ? ' dpot-remote__banner--paused' : ' dpot-remote__banner--playing'}`} role="status">
  366. {state.isPaused ? (
  367. <><Pause size={14} aria-hidden="true" /><span>현재 알림 일시정지 중입니다</span></>
  368. ) : (
  369. <><Play size={14} aria-hidden="true" /><span>현재 알림 재생 중입니다</span></>
  370. )}
  371. </div>
  372. {/* 헤더 */}
  373. <header className="dpot-remote__header">
  374. <div className="dpot-remote__brand">
  375. <span className="dpot-remote__brand-logo">DPOT</span>
  376. <span className="dpot-remote__brand-title">리모콘</span>
  377. </div>
  378. <div className="dpot-remote__header-actions">
  379. <span className={`dpot-remote__connection${connected ? ' dpot-remote__connection--on' : ' dpot-remote__connection--off'}`}>
  380. {connected ? <Wifi size={12} aria-hidden="true" /> : <WifiOff size={12} aria-hidden="true" />}
  381. <span>{connected ? '연결됨' : '연결 끊김'}</span>
  382. </span>
  383. {isPopup && (
  384. <button type="button" className="dpot-remote__close-btn" onClick={() => window.close()} aria-label="닫기">
  385. <X size={16} aria-hidden="true" />
  386. </button>
  387. )}
  388. </div>
  389. </header>
  390. {/* 운영 토글 (음성만/영상만/후원수신) */}
  391. <div className="dpot-remote__sub-controls">
  392. <button type="button" className={`dpot-remote__sub-btn${state.isAccepting ? '' : ' dpot-remote__sub-btn--off'}`} onClick={toggleAccepting}>
  393. {state.isAccepting ? <Bell size={14} aria-hidden="true" /> : <BellOff size={14} aria-hidden="true" />}
  394. <span>{state.isAccepting ? '후원 받는 중' : '후원 안 받음'}</span>
  395. </button>
  396. <button type="button" className={`dpot-remote__sub-btn${state.isAudioOnly ? ' dpot-remote__sub-btn--active' : ''}`} onClick={toggleAudioOnly}>
  397. <Volume2 size={14} aria-hidden="true" />
  398. <span>음성만</span>
  399. </button>
  400. <button type="button" className={`dpot-remote__sub-btn${state.isVideoOnly ? ' dpot-remote__sub-btn--active' : ''}`} onClick={toggleVideoOnly}>
  401. <ImageIcon size={14} aria-hidden="true" />
  402. <span>영상만</span>
  403. </button>
  404. </div>
  405. {/* 통합 테스트 후원 form (크루 미선택=일반, 선택=크루) */}
  406. <fieldset className="dpot-remote__test-form">
  407. <legend className="dpot-remote__test-form-legend">테스트 후원</legend>
  408. <div className="dpot-remote__test-row">
  409. <select
  410. className="dpot-remote__select"
  411. value={selectedCrewID ?? ''}
  412. onChange={e => setSelectedCrewID(e.target.value ? parseInt(e.target.value, 10) : null)}
  413. >
  414. <option value="">크루 없음 (일반 후원)</option>
  415. {crews.map(c => (
  416. <option key={c.id} value={c.id}>크루 · {c.name} ({c.memberCount}명)</option>
  417. ))}
  418. </select>
  419. <select
  420. className="dpot-remote__select"
  421. value={selectedMemberID ?? ''}
  422. onChange={e => setSelectedMemberID(e.target.value ? parseInt(e.target.value, 10) : null)}
  423. disabled={!selectedCrewID || crewMembers.length === 0}
  424. >
  425. <option value="">{selectedCrewID ? '크루원 선택' : '— 크루원 —'}</option>
  426. {crewMembers.map(m => (
  427. <option key={m.id} value={m.id}>{m.nickname}{m.role ? ` (${m.role})` : ''}</option>
  428. ))}
  429. </select>
  430. </div>
  431. <div className="dpot-remote__test-row">
  432. <input
  433. type="text"
  434. className="dpot-remote__input"
  435. placeholder="별명"
  436. value={testName}
  437. onChange={e => setTestName(e.target.value)}
  438. maxLength={30}
  439. />
  440. <input
  441. type="number"
  442. className="dpot-remote__input dpot-remote__input--amount"
  443. placeholder="금액"
  444. value={testAmount > 0 ? testAmount : ''}
  445. onChange={e => setTestAmount(parseInt(e.target.value || '0', 10))}
  446. min={1000}
  447. step={1000}
  448. />
  449. </div>
  450. <textarea
  451. className="dpot-remote__textarea"
  452. placeholder="메시지 (선택)"
  453. value={testMessage}
  454. onChange={e => setTestMessage(e.target.value)}
  455. rows={2}
  456. maxLength={200}
  457. />
  458. <div className="dpot-remote__test-form-footer">
  459. <label className="dpot-remote__checkbox">
  460. <input
  461. type="checkbox"
  462. checked={isTestMode}
  463. onChange={e => setIsTestMode(e.target.checked)}
  464. />
  465. <span>테스트 모드 (위젯에만 표시, DB 통계 미반영)</span>
  466. </label>
  467. <button
  468. type="button"
  469. className="dpot-remote__test-submit"
  470. onClick={submitTestDonate}
  471. disabled={testSubmitting}
  472. >
  473. {testSubmitting ? '전송 중...' : '후원하기'}
  474. </button>
  475. </div>
  476. {!isTestMode && (
  477. <p className="dpot-remote__warn" role="alert">
  478. <AlertTriangle size={14} aria-hidden="true" />
  479. <span>
  480. {selectedCrewID
  481. ? '영구 후원으로 기록됩니다. 활성 크루 세션이 있을 경우 리더보드에 합산됩니다.'
  482. : '영구 후원으로 기록됩니다. 통계에 반영됩니다.'}
  483. </span>
  484. </p>
  485. )}
  486. </fieldset>
  487. {/* 6개 탭 */}
  488. <div className="dpot-remote__tabs" role="tablist">
  489. {TAB_DEFS.map(t => (
  490. <button
  491. key={t.key}
  492. type="button"
  493. role="tab"
  494. aria-selected={activeTab === t.key}
  495. className={`dpot-remote__tab${activeTab === t.key ? ' dpot-remote__tab--active' : ''}`}
  496. onClick={() => setActiveTab(t.key)}
  497. >
  498. <span className="dpot-remote__tab-label">{t.label}</span>
  499. <span className="dpot-remote__tab-count">{counts[t.key]}</span>
  500. </button>
  501. ))}
  502. </div>
  503. {/* 후원 알림 listing */}
  504. <div className="dpot-remote__list-wrap">
  505. <div className="dpot-remote__list-header" role="row">
  506. <span className="dpot-remote__col dpot-remote__col--reorder">순서</span>
  507. <span className="dpot-remote__col dpot-remote__col--name">별명</span>
  508. <span className="dpot-remote__col dpot-remote__col--amount">금액</span>
  509. <span className="dpot-remote__col dpot-remote__col--msg">메시지</span>
  510. <span className="dpot-remote__col dpot-remote__col--time">시간</span>
  511. <span className="dpot-remote__col dpot-remote__col--status">상태</span>
  512. <span className="dpot-remote__col dpot-remote__col--menu">기능</span>
  513. </div>
  514. <div className="dpot-remote__list">
  515. {filtered.length === 0 && <div className="dpot-remote__empty">후원 알림이 없습니다</div>}
  516. {filtered.map(item => {
  517. // queued 항목 중 reorder 가능한 위치 판정
  518. const queuedItems = filtered.filter(q => q.status === 'queued');
  519. const qIdx = queuedItems.findIndex(q => q.alertID === item.alertID);
  520. const canMoveUp = item.status === 'queued' && qIdx > 0;
  521. const canMoveDown = item.status === 'queued' && qIdx >= 0 && qIdx < queuedItems.length - 1;
  522. return (
  523. <div key={item.alertID} className={`dpot-remote__row dpot-remote__row--${item.status}`} role="row">
  524. <span className="dpot-remote__col dpot-remote__col--reorder">
  525. {item.status === 'queued' ? (
  526. <>
  527. <button
  528. type="button"
  529. className="dpot-remote__reorder-btn"
  530. onClick={() => swapAlert(item.alertID, 'up')}
  531. disabled={!canMoveUp}
  532. aria-label="위로"
  533. title="위로"
  534. >
  535. <ChevronUp size={14} aria-hidden="true" />
  536. </button>
  537. <button
  538. type="button"
  539. className="dpot-remote__reorder-btn"
  540. onClick={() => swapAlert(item.alertID, 'down')}
  541. disabled={!canMoveDown}
  542. aria-label="아래로"
  543. title="아래로"
  544. >
  545. <ChevronDown size={14} aria-hidden="true" />
  546. </button>
  547. </>
  548. ) : null}
  549. </span>
  550. <span className="dpot-remote__col dpot-remote__col--name" title={item.sendName}>
  551. {item.isTest && <span className="dpot-remote__test-badge">TEST</span>}
  552. {item.sendName}
  553. </span>
  554. <span className="dpot-remote__col dpot-remote__col--amount">{item.amount.toLocaleString()}원</span>
  555. <span className="dpot-remote__col dpot-remote__col--msg" title={item.message ?? ''}>{item.message ?? '—'}</span>
  556. <span className="dpot-remote__col dpot-remote__col--time">{formatTime(item.createdAt)}</span>
  557. <span className={`dpot-remote__col dpot-remote__col--status dpot-remote__status-badge dpot-remote__status-badge--${item.status}`}>
  558. {item.status === 'playing' && playingMeta[item.alertID]
  559. ? <TimerRing meta={playingMeta[item.alertID]} />
  560. : (STATUS_LABEL[item.status] ?? item.status)}
  561. </span>
  562. <span className="dpot-remote__col dpot-remote__col--menu">
  563. <button
  564. type="button"
  565. className="dpot-remote__menu-btn"
  566. onClick={(e) => toggleMenu(item.alertID, e)}
  567. aria-label="메뉴 열기"
  568. aria-expanded={openMenuID === item.alertID}
  569. >
  570. <MoreVertical size={16} aria-hidden="true" />
  571. </button>
  572. </span>
  573. </div>
  574. );
  575. })}
  576. </div>
  577. </div>
  578. {/* 하단 메인 컨트롤 */}
  579. <div className="dpot-remote__main-controls">
  580. <button
  581. type="button"
  582. className={`dpot-remote__main-btn dpot-remote__main-btn--pause${state.isPaused ? ' dpot-remote__main-btn--active' : ''}`}
  583. onClick={() => { if (!state.isPaused) togglePause(); }}
  584. disabled={state.isPaused}
  585. >
  586. <Pause size={18} aria-hidden="true" />
  587. <span>일시정지</span>
  588. </button>
  589. <button
  590. type="button"
  591. className="dpot-remote__main-btn dpot-remote__main-btn--skip"
  592. onClick={skipCurrent}
  593. >
  594. <SkipForward size={18} aria-hidden="true" />
  595. <span>건너뛰기</span>
  596. </button>
  597. <button
  598. type="button"
  599. className={`dpot-remote__main-btn dpot-remote__main-btn--play${!state.isPaused ? ' dpot-remote__main-btn--active' : ''}`}
  600. onClick={() => { if (state.isPaused) togglePause(); }}
  601. disabled={!state.isPaused}
  602. >
  603. <Play size={18} aria-hidden="true" />
  604. <span>재생</span>
  605. </button>
  606. </div>
  607. {/* 메뉴 dropdown — Portal로 body에 렌더해서 list overflow 클립 회피 */}
  608. {openMenuID !== null && menuPos && typeof document !== 'undefined' && (() => {
  609. const activeItem = queue.find(q => q.alertID === openMenuID);
  610. if (!activeItem) {
  611. return null;
  612. }
  613. return createPortal(
  614. <div
  615. className="dpot-remote__menu-dropdown"
  616. role="menu"
  617. style={{ position: 'fixed', top: menuPos.top, right: menuPos.right }}
  618. >
  619. {activeItem.status === 'queued' && (
  620. <>
  621. <button type="button" className="dpot-remote__menu-item" onClick={() => playNow(activeItem.alertID)}>즉시 재생</button>
  622. <button type="button" className="dpot-remote__menu-item dpot-remote__menu-item--danger" onClick={() => ignoreAlert(activeItem.alertID)}>무시</button>
  623. </>
  624. )}
  625. {activeItem.status === 'playing' && (
  626. <button type="button" className="dpot-remote__menu-item" onClick={() => markDelivered(activeItem.alertID)}>완료 처리</button>
  627. )}
  628. {(activeItem.status === 'failed' || activeItem.status === 'skipped' || activeItem.status === 'ignored' || activeItem.status === 'delivered') && (
  629. <button type="button" className="dpot-remote__menu-item" onClick={() => resendAlert(activeItem.alertID)}>재전송</button>
  630. )}
  631. {activeItem.status === 'skipped' && (
  632. <button type="button" className="dpot-remote__menu-item dpot-remote__menu-item--danger" onClick={() => ignoreAlert(activeItem.alertID)}>무시</button>
  633. )}
  634. </div>,
  635. document.body
  636. );
  637. })()}
  638. </div>
  639. );
  640. }